package lc.util;

import lc.animation.AnimationDataController;
import lc.animation.Keyframe;

import java.net.URL;
import java.nio.FloatBuffer;
import java.nio.IntBuffer;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Hashtable;
import java.util.SortedMap;
import java.util.logging.Logger;

import com.jme.image.Image;
import com.jme.image.Texture;
import com.jme.math.Matrix3f;
import com.jme.math.Matrix4f;
import com.jme.math.Quaternion;
import com.jme.math.Vector3f;
import com.jme.renderer.ColorRGBA;
import com.jme.scene.Controller;
import com.jme.scene.Node;
import com.jme.scene.Spatial;
import com.jme.scene.TriMesh;
import com.jme.scene.state.AlphaState;
import com.jme.scene.state.CullState;
import com.jme.scene.state.MaterialState;
import com.jme.scene.state.TextureState;
import com.jme.scene.state.ZBufferState;
import com.jme.system.DisplaySystem;
import com.jme.util.TextureKey;
import com.jme.util.resource.ResourceLocatorTool;

public final class Mesh {
	static Logger logger = Logger.getLogger(Mesh.class.getName());

	public Material material;
	/**
	 * faceMaterials has one int per face, telling what submaterial to use per face.
	 */
	public SimpleIntArray faceMaterials;
	/**
	 * vertices is an array of positions (Vector3f), indexed into by vertexIndices.
	 */
	public SimpleFloatArray vertices;
	/**
	 * vertexIndices defines the faces; three ints per face. Indexes into vertices.
	 */
	public SimpleIntArray vertexIndices;
	/**
	 * texcoords is an array of texture coordinates, indexed into by textureIndices.
	 */
	public SimpleFloatArray texcoords;
	/**
	 * textureIndices should have as many ints in it as vertexIndices. It indexes into
	 * texcoords in parallel with vertexIndices.
	 */
	public SimpleIntArray textureIndices;
	/**
	 * normals contain one Vector3f normal PER VERTEX INDEX. It is indexed not using
	 * indices, but using a straight array, where vertex N of face F has a normal in
	 * this array at index (N+F*3) (and there are three floats per such index).
	 */
	public SimpleFloatArray normals;
	/**
	 * smGroups should be null or should contain as many ints as there are faces.
	 * It defines a smoothing group per face. Smoothing groups are used to re-calculate
	 * normals, if present.
	 */
	public SimpleIntArray smGroups;
	/**
	 * The name of the mesh.
	 */
	public String name;
	/**
	 * Set ANISOTROPIC to the level of anisotropy you want to support in
	 * textures of converted models.
	 */
	public static float ANISOTROPIC = 2.0f;
	/**
	 * Set TAG_DIRECTORY to a directory name that is the "root" of any texture
	 * filename path. For example, if all your assets live in "data/whatever"
	 * then you can set TAG_DIRECTORY to "data/" and a texture path such as
	 * "c:/foo/bar/data/whatever" will get turned into "data/whatever".
	 */
	public static String TAG_DIRECTORY = null;
	/**
	 * Set RENAME_DDS to true if you want the file extension of all textures
	 * to be changed to ".dds". Note that the such referenced .dds files need
	 * to already exist when you conver the model.
	 */
	public static boolean RENAME_DDS = true;
	/**
	 * REMOVE_DIRECTORY is almost like TAG_DIRECTORY, except the directory
	 * itself is not made part of the path. Thus, you can set REMOVE_DIRECTORY
	 * to "../" and any relative path is removed from the beginning of the
	 * texture path. REMOVE_DIRECTORY is tested before TAG_DIRECTORY.
	 */
	public static String REMOVE_DIRECTORY = null;

	public Mesh(String s) {
		name = s;
	}

	public final void bake(Node node) throws Exception {
		//	If I have smGroups, then re-calc normals
		if (smGroups != null && smGroups.logicalSize == faceMaterials.logicalSize) {
			calculateNormals();
		}
		node.updateWorldVectors();
		Matrix4f xform = Transform.getWorld(node);
		xform.invertLocal();
		boolean smaller = true;
		MeshHasher.Triangle t = new MeshHasher.Triangle();
		//	When we only have a regular material, or a
		//	multi/sub-material with a single child material,
		//	then splitting per-material is wasteful, as
		//	it will all be drawn with the same material anyway.
		//	If I wanted to be 100% correct, I would modulo the
		//	material gotten by the number of sub materials, as
		//	that's what Max actually does. Currently, if there
		//	are 6 material indices, but only 2 sub-materials,
		//	each sub-material will get 3 separate batches.
		int mult = 1;
		if (material.subMaterials == null || material.subMaterials.size() < 2)
			mult = 0;
		for (int targetMaterial = 0; smaller; ++targetMaterial) {
			smaller = false;
			MeshHasher mh = null;
			int[] data = faceMaterials.data;
			//	walk all faces, looking for the target material
			//	At the same time, figure out whether there are any more materials to go
			for (int i = 0, n = faceMaterials.logicalSize; i != n; ++i) {
				int m = data[i] * mult;
				if (m == targetMaterial) {
					if (mh == null) {
						logger.info("Making triangle mesh for material " + m);
						mh = new MeshHasher(texcoords != null, normals != null);
					}
					for (int j = 0; j < 3; ++j) {
						int vi = vertexIndices.data[i*3+j];
						t.pos[j].x = vertices.data[vi*3];
						t.pos[j].y = vertices.data[vi*3+1];
						t.pos[j].z = vertices.data[vi*3+2];
						if (normals != null) {
							t.normal[j].x = normals.data[i*9+j*3];
							t.normal[j].y = normals.data[i*9+j*3+1];
							t.normal[j].z = normals.data[i*9+j*3+2];
						}
						if (texcoords != null) {
							int ti = textureIndices.data[i*3+j];
							t.uv[j].x = texcoords.data[ti*2];
							t.uv[j].y = texcoords.data[ti*2+1];
						}
					}
					mh.add(t);
				}
				else if (m > targetMaterial) {
					smaller = true;
				}
			}
			//	If I have a mesh, then make a TriMesh and add to the node
			if (mh != null) {
				mh.processTransform(xform);
				Material mtl = material.getMaterial(targetMaterial);
				String n = name + ":" + mtl.name;
				logger.info("Emitting trimesh " + n + " for material " + targetMaterial);
				TriMesh mesh = makeTrimesh(n, mh);
				//	now, apply the material information
				applyMaterialToMesh(mtl, mesh);
				node.attachChild(mesh);
			}
		}
	}

	final void calculateNormals() {
		normals = new SimpleFloatArray();
		//	calculate which faces are using the vertex, on a per-vertex basis
		Hashtable<Integer, SimpleIntArray> facesPerVertex = new Hashtable<Integer, SimpleIntArray>();
		for (int i = 0, n = vertexIndices.logicalSize; i != n; ++i) {
			int face = i / 3;
			int ix = vertexIndices.data[i];
			SimpleIntArray sia = facesPerVertex.get(ix);
			if (sia == null) {
				sia = new SimpleIntArray();
				facesPerVertex.put(ix, sia);
			}
			sia.add(face);
		}
		//	Calculate the average normal of faces sharing each vertex.
		//	This does some redundant work, but I prefer the cleaner code
		//	to trying to calculate this data only once per vert/smgroup
		for (int i = 0, n = vertexIndices.logicalSize; i != n; ++i) {
			int vert = vertexIndices.data[i];
			int face = i / 3;
			int smg = smGroups.data[face];
			SimpleIntArray faces = facesPerVertex.get(vert);
			Vector3f normal = new Vector3f(0, 0, 0);
			for (int j = 0, m = faces.logicalSize; j != m; ++j) {
				int fc = faces.data[j];
				if (smGroups.data[fc] == smg) {
					normal.addLocal(calcFaceNormal(fc));
				}
			}
			if (normal.lengthSquared() < 1e-8) {
				logger.warning("Vertex normal is degenerate for face " + face + " in mesh " + name);
				normal = new Vector3f(0, 1, 0);
			}
			else
				normal.normalizeLocal();
			normals.add(normal);
		}
	}

	final Vector3f calcFaceNormal(int fc) {
		Vector3f a = getVertex(vertexIndices.data[fc*3]);
		Vector3f b = getVertex(vertexIndices.data[fc*3+1]);
		Vector3f c = getVertex(vertexIndices.data[fc*3+2]);
		b.subtractLocal(a);
		c.subtractLocal(a);
		b.cross(c, a);
		return a;
	}

	final Vector3f getVertex(int v) {
		Vector3f ret = new Vector3f(
				vertices.data[v*3],			//	x
				vertices.data[v*3+1],		//	y
				vertices.data[v*3+2]		//	z
				              );
		return ret;
	}

	public static TriMesh makeTrimesh(String n, MeshHasher mh) throws Exception {
		TriMesh mesh = new TriMesh(n);
		FloatBuffer fbVert = FloatBuffer.wrap(mh.getVertices().data, 0, mh.getVertices().logicalSize);
		FloatBuffer fbNorm = (mh.getNormals() == null) ? null :
			FloatBuffer.wrap(mh.getNormals().data, 0, mh.getNormals().logicalSize);
		FloatBuffer fbTex = (mh.getTexcoords() == null) ? null :
			FloatBuffer.wrap(mh.getTexcoords().data, 0, mh.getTexcoords().logicalSize);
		assert(fbNorm == null || fbNorm.limit() == fbVert.limit());
		assert(fbTex == null || fbTex.limit() == fbVert.limit()/3*2);
		IntBuffer ibFace = IntBuffer.wrap(mh.getFaceList().data, 0, mh.getFaceList().logicalSize);
		mesh.reconstruct(fbVert, fbNorm, null, fbTex, ibFace);
		return mesh;
	}

	public final static String mungePath(String path) {
		if (Mesh.RENAME_DDS) {
			path = path.substring(0, path.lastIndexOf('.')) + ".dds";
		}
		if (Mesh.REMOVE_DIRECTORY != null) {
			int ix = path.lastIndexOf(Mesh.REMOVE_DIRECTORY);
			if (ix >= 0) {
				path = path.substring(ix + Mesh.REMOVE_DIRECTORY.length());
			}
		}
		if (Mesh.TAG_DIRECTORY != null) {
			int ix = path.lastIndexOf(Mesh.TAG_DIRECTORY);
			if (ix > 0) {
				path = path.substring(ix);
			}
		}
		return path;
	}

	public final static ColorRGBA white = new ColorRGBA(1, 1, 1, 1);
	public final static ColorRGBA gray = new ColorRGBA(0.5f, 0.5f, 0.5f, 0.5f);
	public final static ColorRGBA black = new ColorRGBA(0, 0, 0, 0);

	public final static void applyMaterialToMesh(Material mtl, Spatial mesh) {
		logger.info("Compiling material named " + mtl.name);
		MaterialState ms = DisplaySystem.getDisplaySystem().getRenderer().createMaterialState();
		ms.setEnabled(true);
		ms.setAmbient(mtl.propertyCombinedColor(Material.AMBIENT, white));
		ms.setDiffuse(mtl.propertyCombinedColor(Material.DIFFUSE, white));
		ms.setSpecular(mtl.propertyCombinedColor(Material.SPECULAR, gray));
		ms.setEmissive(mtl.propertyCombinedColor(Material.EMISSIVE, black));
		ms.setShininess(mtl.propertyFloat(Material.POWER, 30));
		mesh.setRenderState(ms);

		boolean hasAlpha = mtl.hasOpacity();
		logger.info("Material " + (hasAlpha ? "is" : "is not") + " translucent.");

		ZBufferState zbs = DisplaySystem.getDisplaySystem().getRenderer().createZBufferState();
		zbs.setEnabled(true);
		zbs.setFunction(ZBufferState.CF_LEQUAL);
		zbs.setWritable(hasAlpha ? false : true);
		mesh.setRenderState(zbs);

		CullState cs = DisplaySystem.getDisplaySystem().getRenderer().createCullState();
		cs.setEnabled(true);
		cs.setCullMode(CullState.CS_BACK);
		mesh.setRenderState(cs);

		AlphaState as = DisplaySystem.getDisplaySystem().getRenderer().createAlphaState();
		as.setEnabled(true);
		as.setBlendEnabled(hasAlpha ? true : false);
		as.setTestEnabled(false);
		as.setSrcFunction(AlphaState.SB_SRC_ALPHA);
		as.setDstFunction(AlphaState.DB_ONE_MINUS_SRC_ALPHA);
		mesh.setRenderState(as);

		TextureMap diffuseMap = mtl.propertyTexture(Material.DIFFUSE, null);
		if (diffuseMap != null) {
			URL texUrl = ResourceLocatorTool.locateResource(ResourceLocatorTool.TYPE_TEXTURE,
					mungePath(diffuseMap.path));
			if (texUrl != null) {
				logger.info("Found texture at location " + texUrl.toString());
				Texture tex = new Texture();
				TextureKey tk = new TextureKey(texUrl, true, Image.GUESS_FORMAT_NO_S3TC);
				tex.setTextureKey(tk);
				tex.setImageLocation(tk.getLocation().toString());
				tex.setAnisoLevel(Mesh.ANISOTROPIC);
				tex.setMipmapState(Texture.MM_LINEAR_LINEAR);
				tex.setFilter(Texture.FM_LINEAR);
				tex.setWrap(Texture.WM_WRAP_S_WRAP_T);
				TextureState ts = DisplaySystem.getDisplaySystem().getRenderer()
						.createTextureState();
				ts.setEnabled(true);
				ts.setTexture(tex);
				mesh.setRenderState(ts);
				mesh.setTextureCombineMode(TextureState.REPLACE);
			}
		}
		mesh.updateRenderState();
	}

	public static final void applyAnimation(SortedMap<Float, Keyframe> keyframes, Node node, float endTime) {
		node.addController(new AnimationDataController(keyframes, node.getName(), endTime));
	}

	/**
	 * Walk through the node tree and convert the animation coordinate system
	 * from global space to node space, and bake in the local node transform. That
	 * way, each node can just apply a Keyframe and be good when playing back.
	 *
	 * @param node The root of the tree to bake animations for.
	 * @param endTimeInSeconds The time at which the animation ends (any frame at or after this time is not used)
	 */
	public static final void bakeAnimations(Node node, float endTimeInSeconds) {
		boolean done = false;
		for (Controller c : node.getControllers()) {
			if (c instanceof AnimationDataController) {
				done = true;
				calculateLocalAnimation(((AnimationDataController)c).getKeyframes().values(), node);
				break;
			}
		}
		if (!done) {
			node.addController(new AnimationDataController(null, node.getName(), endTimeInSeconds));
		}
		for (Spatial s : node.getChildren()) {
			if (s instanceof Node) {
				bakeAnimations((Node)s, endTimeInSeconds);
			}
		}
	}

	static final void calculateLocalAnimation(Collection<Keyframe> keyframes, Spatial node) {
		Quaternion rotation = node.getLocalRotation();
		for (Keyframe kf : keyframes) {
			if (kf.rotation != null)
				kf.rotation.multLocal(rotation);
		}
	}
}
